Skip to content

feat: one HarnessClient, one daemon, one MCP path - #974

Open
chughtapan wants to merge 57 commits into
mainfrom
docs/main-owned-harness-adrs
Open

feat: one HarnessClient, one daemon, one MCP path#974
chughtapan wants to merge 57 commits into
mainfrom
docs/main-owned-harness-adrs

Conversation

@chughtapan

@chughtapan chughtapan commented Aug 5, 2026

Copy link
Copy Markdown
Owner

PR #974 is the production bridge into the v2 cutover: one adapter contract, one daemon, one loopback MCP path, and no retained v1 CLI/socket/send route.

Outcome

  • HarnessClient is the sole runtime-adapter capability. OpenClaw and NanoClaw no longer construct MoltZapService, MoltZapChannelCore, transports, or daemon lifecycle directly.
  • One moltzapd owns one profile slot and one fixed /mcp endpoint. Before identity commit the catalog is register + status; afterward it is status, search_agents, search_conversations, read_conversation, start_conversation, and reply. Receive remains the MCP subscription, not another tool.
  • A profile slot carries its endpoint before it carries identity: { agentName, mcpPort, agentId?, apiKey? }.
  • Presentation recovery is endpoint-local. HarnessClient stores per-profile checkpoints, reconstructs context from search/history, and never recreates reply authority from history.
  • The bespoke moltzap CLI, Unix socket, local RPC dialect, and generic adapter send are removed.
  • Message ordering is database-owned. messages.seq is an always-generated PostgreSQL BIGINT identity; same-conversation inserts serialize on the conversation row so checkpoints cannot skip a still-pending lower commit.

Landing hardening

The refreshed branch includes the latest main and closes the landing findings found during the altitude/readability pass:

  • serialized OpenClaw and NanoClaw lifecycle transitions, including concurrent setup and teardown-during-setup;
  • preserved host-facing OpenClaw account identifiers while canonicalizing only internal ownership keys;
  • made server message order correct across rollback, restart, and multiple processes;
  • rejected old or hand-mutated database shapes at startup;
  • split the oversized NanoClaw lifecycle suite behind a shared fixture and documented the channel boundary;
  • reconciled stale simulator, eval, client, schema, and operator guidance;
  • removed prior blind-review verdicts from ordinary provenance paths so fresh reviewers cannot be contaminated indirectly.

Breaking

  • Existing three-field ~/.moltzap/config.json profiles fail strict decode. Add agentName and a stable nonzero mcpPort; there is no pre-launch migration shim.
  • Databases created from the previous schema must be recreated; there is no in-place migration to the database-owned ordering column.
  • @moltzap/client ships only the moltzapd binary.
  • Removed CLI, socket, generic-send, and retired client exports are intentionally absent.

Decisions and review

Main-owned accepted ADRs:

Final semantic candidate:

  • commit: 8f70a1e47558e95dbfe957e563a59099b81afe28
  • tree: 2297c39b33663521167b68fb38927df195a0ef2b
  • archive SHA-256: 7b83bf924871b5e2a591939e2d9dcb5b9e3fd8290c59c275d24add29f496488a

The seventh fresh isolated teammate review passed all six fixed questions with no blockers: review record. Every earlier failed or invalid attempt is retained alongside it for auditability.

Maintainer acceptance of the passing review: PENDING. Reviewer prose is not self-certifying; do not merge until that acceptance is recorded.

Verification

Local verification is green:

  • full workspace build;
  • full workspace lint after build, with zero warnings/errors;
  • format check;
  • affected client, server, OpenClaw, NanoClaw, simulator, protocol, and architecture targets;
  • client/NanoClaw/OpenClaw unit and integration coverage;
  • server unit, integration, and conformance suites;
  • generated-document drift, broken links, Mermaid, ADR/generator gates, and import/constant checks;
  • package pre-push guards and affected v2 typechecks.

GitHub CI for this pushed revision is pending.

Deliberately tracked outside this v1 landing

The last three are inputs to the four-layer cutover, not reasons to expand the retiring v1 stack.

Superseded stack

This PR contains the production work formerly split across #943, #944, #945, #946, #947, #948, #950, #951, #953, #954, #955, #959, #960, #961, #962, and #972. Their histories remain readable.

chughtapan and others added 30 commits August 3, 2026 22:24
statusCommandRpc was simultaneously the Unix-socket RPC and the schema
source for two surviving consumers: the MCP status tool and the harness
client's identity read. That made the plane which outlives the socket
depend on the plane which does not.

The harness plane now states the status contract itself and exposes it as
JSON Schemas, derived types, and a strict decoder. The socket surface
states the same shape independently, because a schema constructor cannot
cross the boundary; drift is still a compile error either way, since each
side implements a handler against its own schema.

Dropping the RPC handler context also removes the Rpc.isWrapper unwrap and
its accompanying cast from the status tool registration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
A profile record was exactly {agentId, apiKey, agentName}, so nothing
could tell a daemon which port to bind and moltzapd required a
caller-supplied --port. No production code could compute an MCP URL.

The record becomes a slot: agentName and a required mcpPort, plus the
identity pair agentId and apiKey, which are written together at Registry
commit or absent together before it. moltzapd resolves its own port from
the slot it was named with, so --port is gone from the published binary.
loadServiceConfig now distinguishes an absent slot from a slot with no
committed identity, which the registration surface will branch on.

Strict decode has no compatible intermediate: a record either carries
mcpPort or fails to decode. Every writer moves in this commit, and the
simulator and packaged-daemon fixtures choose a port before writing the
slot rather than handing one to the daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
Nothing in production could build a HarnessClient: the only code that
started a daemon and connected to it lived in test-utils, and it demanded
a caller-supplied port and an injected checkpoint store.

harnessClientForProfile takes a profile name and nothing else. It starts
the slot's own daemon child, derives the loopback endpoint from the same
persisted port the daemon binds, and provides a file-backed checkpoint
store. The checkpoint directory is keyed by profile name because the store
must be provided before the client reads its identity from the daemon's
status tool, and one slot is exactly one AgentId.

The daemon supervisor moves out of test-utils to the source root; only the
port reservation a test performs before writing a slot stays behind. The
packaged round-trip test now drives the production composition instead of
a parallel test-only path with an in-memory store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
The plugin chose between a HarnessClient and a directly constructed
MoltZapService at runtime, and only the second arm was reachable in
production, because nothing could compute a daemon endpoint. The slot now
carries its port, so the account id alone resolves a client.

startAccount acquires that client unconditionally and drains it. The legacy
arm goes with everything that existed to serve it: the gateway service and
core constructors, the inbound bridge that synthesized a turn from a raw
core message, connection-status reporting, the outbound dispatcher, the
active-service map, and the legacy half of the gateway lifecycle.

The directory section goes with it. Serving listPeers and listGroups
through the client would publish agent and conversation search on the
adapter-facing surface, which the accepted HarnessClient record keeps as
MCP management operations. A harness-backed account already returned empty
lists, so this makes an existing silent gap explicit.

Proactive sends into an existing conversation and MoltZapService.sendToAgent
go too. Repeated proactive DMs to one agent no longer share a conversation;
that regression is tracked separately.

The docker suites and their container harness are deleted rather than
carried: they were gated behind an image that has no Dockerfile here and no
CI step builds one, so they had never executed. No integration suite
remained in the package afterward, so its setup and config go as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
The adapter carried two constructors and production only ever reached the
one that built MoltZapService and MoltZapChannelCore directly, because
nothing could resolve a daemon endpoint. The slot now carries its port, so
the profile name alone resolves a client.

NanoClaw registers its channel through a zero-argument factory that runs at
module import, so there is no seam to inject a client through. The adapter
therefore owns the scope itself: it acquires in setup and releases in
teardown. That inverts the borrowed-client contract, so the borrowing
constructor goes rather than surviving beside it.

The simulator writes the slot and stops there. It does not start a daemon:
the adapter starts its own, and a real nanoclaw checkout has no simulator to
start one for it. Two daemons would bind the same slot's single port.

The channel stays one file. The asset copier installs exactly this module
into a downloaded checkout, so acquisition lives here or behind a client
export, never in a sibling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P76aaa1STr3WPZ3nDascta
…lane

The daemon bound its listener only after loadServiceConfig succeeded, so a
slot without a committed identity had nowhere to register and the
registration catalog stayed empty. The listener now binds first and its
catalog follows slot state: register and status before commit, the six
active tools after. The official SDK builds a fresh server per request, so
the transition is a phase read rather than a rebind — the URL never changes
and an open client sees the new catalog on its next tools/list.

/register/mcp goes with it. main's accepted 20260728-endpoint-daemon-speaks-
modern-mcp fixes host and path for one loopback /mcp; the two-path shape is
admitted only on v2, whose records govern v2/* and not packages/*.

Registration was the CLI's last unique capability, so the whole `moltzap`
command tree, the Unix socket plane it spoke to, and the local history
surface that existed only to serve that socket all go. `moltzapd` is now the
package's only bin. The socket-absence assertions in the daemon integration
tests go too: with nothing left that could bind a socket, they assert nothing.

scripts/quickstart.sh wrote the pre-slot three-field profile shape, which
strict decode has rejected since mcpPort became required. It now writes a
port per slot and starts daemons instead of CLI commands.

docs/spec/cli.md and docs/spec/endpoints/daemon.md are main-resident v2
content that v2 itself has already deleted. Both gain a scope note rather
than a deletion, so a reader cannot mistake them for a production contract.
The server counts a closed socket asynchronously, so asserting the count
the instant a scope closes is a race. It passed before only because
`beginShutdown` also stopped the Unix socket server, and that work bought
enough latency to hide it; deleting the socket plane removed the latency.

This file already carries `waitForConnectionCount` for exactly this reason
and its sibling process test already uses it. The teardown assertions now
do too.
…bin map

Only "clean-slate cannot import production" was enforced. Three rules now
hold the shape the cutover produced: adapters may reach @moltzap/client only
through its published adapter-facing subpaths, no source may import daemon-side
machinery by name, and the client's export and bin maps are pinned.

The subpath rule alone was insufficient — channel-base re-exported
MoltZapChannelCore and ChannelService, so any adapter could have taken the
long way round. That barrel now exports the seven presentation names its
consumers actually use; both classes have no consumer outside packages/client.

The rules scan shipped sources only. Test scaffolding legitimately drives a
peer agent and registers fixtures, and none of it reaches a user.

The three client-side conformance configs matched no tests and ran
passWithNoTests, so four CI steps reported green for nothing. Deleted rather
than left standing in for an unmet criterion; the deferral is recorded in #926.
The socket support module went with the Unix RPC plane; its JSDoc header
outlived it and now labels the wrong export.
`20260801-harness-client-owns-runtime-context` states the client "stores
stable per-conversation presentation checkpoints locally. After restart it
uses search and history reads to rebuild context from those positions",
"presents context at most once during normal operation", and that "history
reads rebuild context only and never recreate reply authority". Nothing
tested any of it.

Three client lifetimes against one slot. Each spawns its own daemon; only
the checkpoint directory survives between them. The second lifetime must not
re-present the content the first already carried, and its bound reply must
still land in the live conversation. Deleting the checkpoint directory
restores the full re-read, so the narrowing is the checkpoints and not a
recency window.

A turn's reply is bound to the MCP client that produced it, so the reply
round trip runs inside the lifetime that owns that client rather than
escaping its scope.
# Conflicts:
#	.github/workflows/ci.yml
#	package.json
Main renamed packages/simulator/src/runtime/ to src/agents/ and moved agent
runtimes into containers, so the four files this branch edited there are gone.

The branch reserved a loopback port on the simulator host with
reserveSlotMcpPort(). That contradicts the contract both sides already
document: profile.ts says mcpPort is "operator-supplied and stable ... nothing
discovers, allocates, or falls back", and this branch's own CHANGELOG entry
says the port "is yours to choose". Host-side reservation is also meaningless
once each agent owns a network namespace, and wrong on the GKE path, where the
container does not run on the simulator's host at all.

So the port is a constant: SLOT_MCP_CONTAINER_PORT sits beside the gateway
ports the same container binds, and serializeMoltZapProfileConfig now emits
mcpPort at both container call sites. That also closes a gap on main, where
profileRecordSchema required mcpPort but nothing in production supplied it.
Allocation stays a test concern, where reserveTestMcpPort already lives.

check-adr-shape compared a record only against the first parent, so a merge
that adopts the other side's already-landed record read as an unexplained
rewrite. It now recognizes content identical to the incoming parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mintlify

mintlify Bot commented Aug 11, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
moltzap 🟢 Ready View Preview Aug 11, 2026, 6:37 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chughtapan

Copy link
Copy Markdown
Owner Author

Post-commit activation retry decision source event

This comment is agent-authored under the maintainer account. It durably transcribes the public decision exchange that governs the registration recovery correction in this pull request. It is evidence, not normative authority.

Source gap. The current Codex API surface did not expose a native session identifier, message identifier, parent locator, or stored event timestamp to the agent. The GitHub comment timestamp is the time of this mechanical transcription, not a reconstructed source timestamp. The named decision-maker must review this linkage when accepting the ADR and blind-review result.

Agent prompt, current Codex workspace session, stored role assistant; retained literally:

One ADR-sensitive behavior needs your explicit sign-off before I freeze the candidate: if Registry commit and local persistence succeed but the daemon’s first network activation fails, I recommend that a repeated register call resume activation and return the already-committed identity without a second Registry call. A lost Registry response before local persistence remains non-idempotent. I’ve implemented the narrow retry with a fault test; tell me if you want the stricter alternative instead.

Human reply, current Codex workspace session, stored role user; retained literally:

I accept that

No rationale, motive, confidence, or urgency is inferred from the reply.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant